Whisper Isle 遊戲連結(PC Only):whisperisle.app(註:專案每日持續高速演進,撰文當下線上已推進至 v0.2.0)
昨天在 Day 1 分享了專案第一天設計了,Client 只送意圖、狀態單向投影、UI 與 Canvas 徹底隔離。但光在 CLAUDE.md 裡寫下規定是不夠的。規定只是名義上的約束(AI 很常因為幻覺繞過限制),如果工程架構沒有提供相應的約束力,當專案長大,功能變多,AI 在幾輪對話後依然會順著阻力最小的路徑(省 Token),把維持的邊界逐漸踩爛。在開發初期的選型拍板,我得出的結論是:對 AI 友善的架構,跟對人友善的架構,本質上是同一件事,極致的邊界隔離、單一真相來源、以及盡可能便宜快速的驗證迴圈。
做連線遊戲最怕的一種內耗,叫做「前後端合約不同步」。
如果前後端分開成兩個獨立的 Git repo,AI 在維護時很容易會呈現 context 割裂的狀態。今天在前端改了一個移動封包的欄位名(例如把 targetX 改成 x),AI 在沒有後端上下文的情況下,根本不知道後端已經默默壞掉了;反之亦然。甚至前端寫了一套 interface Player,後端又寫了一套 class Player,兩邊欄位型別隨時間逐漸飄移。因此專案開工的第一個 commit(0592813),就是先確立 pnpm workspaces 的 monorepo 架構:
whisper-isle/
├── client/ # Vite + 原生 DOM(初期 POC 無框架)+ Pixi.js v8
├── server/ # Colyseus 0.17 + Express + Node.js
├── packages/
│ └── shared/ # 協定常數、訊息型別、數學工具(前後端唯一真相)
├── pnpm-workspace.yaml
└── tsconfig.base.json
這個 monorepo 結構立刻為我們帶來了兩個決定性的好處:
1. 協定與常數
所有前後端共用的東西,例如伺服器模擬頻率 SIMULATION_TICK_RATE = 20、訊息列舉 ClientMessage.MoveTo、基礎向量運算,全部收斂在 packages/shared。
前端與後端只透過 @whisper-isle/shared 引用。AI 就算想偷懶,也找不到地方去硬寫第二份副本。(第一天這裡只有一個 src/index.ts,直到兩個月後程式碼膨脹,我們才進一步將它拆成 19 個模組)
2. 編譯器防線(pnpm typecheck)
AI 最可靠的防線不是它的自省能力,而是編譯器的報錯。在 monorepo 裡,根目錄一行 pnpm typecheck(也就是全 workspace 執行 tsc --noEmit),能掃描 client、server 與 shared。當 AI 在 shared 裡修改或刪除任何一個型別欄位,只要跑一次 typecheck,兩端所有斷裂的地方會瞬間浮出水面。這把修復契約 bug 的成本壓到了最低。
在 2D 網頁遊戲領域,Phaser 幾乎是統治級的存在,自帶相機系統、Arcade 物理引擎、音效播放器、Tilemap 解析器、甚至內建了按鍵管理。但我刻意避開了 Phaser,選擇了相對底層的 Pixi.js v8。
原因很簡單:Phaser 的全包設計,在多人伺服器架構下,很有可能會成為 AI 犯錯的溫床,Phaser 內建了強大的物理碰撞和遊戲迴圈。當你叫 AI 實作「角色走到障礙物前要停下」時,如果用 Phaser,AI 的第一直覺一定是打開 Phaser 的 Arcade 物理系統,在前端掛上 collider。
這就直接踩碎了 server 端的阻擋,client 端一旦有了自己的物理世界,很快就會出現「前端撞牆停住,伺服器卻認為你沒撞牆繼續走」的穿牆漂移問題。而 Pixi.js 本質上只是個純粹的 2D WebGL/WebGPU 渲染庫。它沒有物理系統、沒有遊戲邏輯概念、沒有內建狀態管理。它的職責就只有一個:把場景樹上的 Sprite 和 Container 畫出來。這種看似簡陋的限制,反而很好約束住 AI:

在即時多人同步上,我們選擇了 Colyseus 0.17。它提供了房間生命週期管理,以及基於二進位 schema 的狀態增量廣播(@colyseus/schema)。
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"experimentalDecorators": true,
"useDefineForClassFields": false
}
}
這個 useDefineForClassFields: false 是一個細節。在 TypeScript 5.x 中,類別欄位的預設編譯行為符合標準 ECMAScript 規格(使用 Object.defineProperty)。但這會直接覆蓋掉 Colyseus Schema decorator 注入的 getter 和 setter。如果漏設了這行,伺服器上的狀態變更(例如玩家血量扣減)將無法被 Colyseus 監聽到,增量封包根本不會送出。而且完全不會報錯,只會呈現幽靈般的「數值改了但前端永遠沒收到」。

做一個 MMO 遊戲,大家的第一反應通常是先把 PostgreSQL 或 MySQL 跑起來,建好 Users 表、Characters 表、Items 表。但在 Whisper Isle 開發的前四週,我們的資料庫連線是完全關閉的(環境變數設為 AUTH_MODE=disabled)。所有的遊戲世界狀態、玩家座標、當前背包、怪物血量,全部只活在伺服器的記憶體裡。伺服器一旦重啟,所有資料全部蒸發。
在專案的前 30 天,核心目標只有一個:驗證核心手感與遊戲迴圈。角色的跑速要多少?攻擊觸發多長?怪物的掉落物格式長什麼樣?背包是一維陣列還是二維?
在這些核心玩法定型前,資料結構每天都在改寫。如果第一天就接了關聯式資料庫,AI 每做一次數值調整或機制重構,就必須伴隨著:寫 migration 檔案、寫 rollback 邏輯、更新 local seed、處理資料庫連線超時與交易死鎖。
無 DB 反而讓整個系統變成了純粹的記憶體映射。AI 要改角色結構,只要改動 TypeScript 的一個 class,存檔後 hot reload 完成就能立刻實測。加速迭代循環,在第一個月完成了職業技能、地圖切換、怪物 AI、仇恨系統與死亡掉落等數十個系統的雛形。
直到第五週,核心機制與資料形狀已經穩定,我們才正式引入 PostgreSQL 和遷移腳本(這部分留在後面詳談)。過早引入持久化,只會變成綁住手腳的沉重包袱。
這些決策表面上是為了一款 MMO 的性能與架構,但更底層的原因是,我們必須讓 AI 自己打造一個不容易犯錯,且犯錯後能被編譯器揪出來的環境。